<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ERC721 NFT 交互工具</title>
  <!-- Vue 3 CDN -->
  <script src="js/vue.global.prod.js"></script>
  <!-- Ethers v5 CDN -->
  <script src="js/ethers.umd.min.js"></script>
  <style>
    :root {
      --bg:#0b0e14; --card:#11151f; --muted:#9aa4b2; --text:#e6eaf2; --accent:#4f8cff; --ok:#20c997; --warn:#ffc107; --err:#ff6b6b;
    }
    * { box-sizing: border-box; }
    body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; background: var(--bg); color: var(--text); }
    a{ color: var(--accent); }
    .wrap { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
    .grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:16px; }
    .card { background: var(--card); border:1px solid #1f2430; border-radius:16px; padding:16px; box-shadow: 0 6px 24px rgba(0,0,0,.25); }
    .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
    .row > * { flex: 1 1 auto; }
    .btn { background: var(--accent); color:#fff; border:none; padding:10px 14px; border-radius:12px; cursor:pointer; font-weight:600; }
    .btn:disabled { opacity:.6; cursor:not-allowed; }
    .btn.secondary { background:#2a2f3b; color:var(--text); }
    .btn.warn { background: var(--warn); color:#222; }
    .btn.ok { background: var(--ok); }
    .input, select { width:100%; padding:10px 12px; border-radius:12px; border:1px solid #293140; background:#0f1320; color:var(--text); outline: none; }
    .label { font-size:12px; color: var(--muted); margin-bottom:6px; }
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
    .kvs { display:grid; grid-template-columns: 200px 1fr; gap:10px; }
    .pill { font-size: 12px; border:1px solid #2a3040; border-radius:999px; padding:4px 10px; color: var(--muted); }
    .footer { text-align:center; color: var(--muted); margin-top: 18px; }
    .title { font-size:20px; font-weight:800; margin:0 0 8px; }
    .subtitle { color: var(--muted); margin-bottom: 12px; }
    .hr { height:1px; background:#1e2433; margin: 10px 0 16px; border:0; }
    .badge-ok{ color: var(--ok); }
    .badge-err{ color: var(--err); }
    code { background:#0f1320; border:1px solid #21283a; padding:2px 6px; border-radius:6px; }
    .nft-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 16px; }
    .nft-card { background: #0f1320; border: 1px solid #1f2434; border-radius: 12px; padding: 12px; text-align: center; }
    .nft-image { width: 100%; height: 160px; object-fit: cover; border-radius: 8px; background: #1a1f2e; }
    .nft-id { font-size: 14px; font-weight: 600; margin: 8px 0 4px; }
    .nft-owner { font-size: 12px; color: var(--muted); }
    @media (max-width: 900px) {
      .kvs { grid-template-columns: 1fr; }
      .nft-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
    }
  </style>
</head>
<body>
  <div id="app" class="wrap">
    <div class="grid">
      <!-- 连接钱包和合约加载 -->
      <div class="card" style="grid-column: span 12;">
        <div class="row" style="justify-content: space-between; align-items: center; gap:8px">
          <div>
            <div class="title">ERC721 NFT 交互工具</div>
          </div>
          <div class="row" style="gap:8px; flex:0 0 auto; align-items: center">
            <span class="pill">网络：<span class="mono">{{ prettyChain(chainId || '未知') }}</span></span>
            <input class="input mono" placeholder="合约地址" v-model.trim="tokenAddress" style="width: 400px;" />
            <button class="btn" @click="loadToken" :disabled="!connected || !tokenAddress">加载合约</button>
            <button class="btn" @click="connect" :disabled="connecting">{{ connected? short(account): (connecting? '连接中…':'连接钱包') }}</button>
            <span class="pill" v-if="tokenMeta.name">已加载：{{ tokenMeta.symbol }}</span>
          </div>
        </div>
        <div class="subtitle" v-if="tokenError">
          <span class="badge-err">⚠</span> {{ tokenError }}
        </div>
      </div>

      <!-- 左侧面板：NFT信息和铸造功能 -->
      <div class="card" style="grid-column: span 6;" v-if="tokenMeta.name">
        <div class="title">NFT 信息</div>
        <div class="kvs mono">
          <div>名称</div><div>{{ tokenMeta.name }}</div>
          <div>符号</div><div>{{ tokenMeta.symbol }}</div>
          <div>总供应量</div><div>{{ tokenMeta.totalSupply }}</div>
          <div>我的NFT数量</div><div>{{ myNFTs.length }}</div>
        </div>
        <hr class="hr">

        <!-- 铸造NFT -->
        <div style="margin-bottom: 16px;">
          <div class="label">铸造NFT (mint)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="mint.to" placeholder="接收地址" />
            </div>
            <div style="flex:0.7">
              <input class="input mono" v-model.trim="mint.tokenId" placeholder="Token ID" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn ok" @click="doMint">铸造</button>
            </div>
          </div>
          <div class="subtitle" v-if="mintHash">
            交易：<a :href="explorerTx(mintHash)" target="_blank" rel="noreferrer">{{ short(mintHash) }}</a>
          </div>
        </div>

        <!-- 查询NFT所有者 -->
        <div style="margin-bottom: 16px;">
          <div class="label">查询NFT所有者 (ownerOf)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="query.tokenId" placeholder="Token ID" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" @click="queryOwner">查询</button>
            </div>
          </div>
          <div style="margin-top:6px" class="mono">{{ query.owner }}</div>
        </div>

        <!-- 查询NFT URI -->
        <div>
          <div class="label">查询NFT元数据URI (tokenURI)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="query.tokenIdURI" placeholder="Token ID" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" @click="queryTokenURI">查询</button>
            </div>
          </div>
          <div style="margin-top:6px" class="mono">{{ query.tokenURI }}</div>
        </div>
      </div>

      <!-- 右侧面板：NFT转账和授权功能 -->
      <div class="card" style="grid-column: span 6;" v-if="tokenMeta.name">
        <div class="title">NFT 操作</div>

        <!-- NFT转账 -->
        <div style="margin-bottom: 16px;">
          <div class="label">NFT转账 (transferFrom)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="transfer.from" placeholder="发送者地址" />
            </div>
            <div style="flex:1">
              <input class="input mono" v-model.trim="transfer.to" placeholder="接收地址" />
            </div>
            <div style="flex:0.7">
              <input class="input mono" v-model.trim="transfer.tokenId" placeholder="Token ID" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" @click="doTransfer" :disabled="!transfer.from || !transfer.to || !transfer.tokenId">转账</button>
            </div>
          </div>
          <div class="subtitle" v-if="transferHash">
            交易：<a :href="explorerTx(transferHash)" target="_blank" rel="noreferrer">{{ short(transferHash) }}</a>
          </div>
        </div>

        <!-- NFT授权 -->
        <div style="margin-bottom: 16px;">
          <div class="label">NFT授权 (approve)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="approve.to" placeholder="被授权者地址" />
            </div>
            <div style="flex:0.7">
              <input class="input mono" v-model.trim="approve.tokenId" placeholder="Token ID" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn warn" @click="doApprove">授权</button>
            </div>
          </div>
          <div class="subtitle" v-if="approveHash">
            交易：<a :href="explorerTx(approveHash)" target="_blank" rel="noreferrer">{{ short(approveHash) }}</a>
          </div>
        </div>

        <!-- 设置授权操作员 -->
        <div style="margin-bottom: 16px;">
          <div class="label">设置授权操作员 (setApprovalForAll)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="approvalAll.operator" placeholder="操作员地址" />
            </div>
            <div style="flex:0.7">
              <select class="input" v-model="approvalAll.approved">
                <option value="true">授权</option>
                <option value="false">取消授权</option>
              </select>
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" @click="doSetApprovalForAll">设置</button>
            </div>
          </div>
          <div class="subtitle" v-if="approvalAllHash">
            交易：<a :href="explorerTx(approvalAllHash)" target="_blank" rel="noreferrer">{{ short(approvalAllHash) }}</a>
          </div>
        </div>

        <!-- 查询授权状态 -->
        <div>
          <div class="label">查询授权状态 (getApproved)</div>
          <div class="row">
            <div style="flex:1">
              <input class="input mono" v-model.trim="queryApproval.tokenId" placeholder="Token ID" />
            </div>
            <div style="flex:0 0 auto">
              <button class="btn secondary" @click="queryApproval">查询</button>
            </div>
          </div>
          <div style="margin-top:6px" class="mono">{{ queryApproval.result }}</div>
        </div>
      </div>

      <!-- 我的NFT展示 -->
      <div class="card" style="grid-column: span 12;" v-if="tokenMeta.name && myNFTs.length > 0">
        <div class="title">我的 NFT 收藏</div>
        <div class="nft-grid">
          <div class="nft-card" v-for="nft in myNFTs" :key="nft.tokenId">
            <img :src="nft.image || 'https://via.placeholder.com/160x160/1a1f2e/9aa4b2?text=NFT'" :alt="`NFT #${nft.tokenId}`" class="nft-image" />
            <div class="nft-id">#{{ nft.tokenId }}</div>
            <div class="nft-owner">所有者: {{ short(nft.owner) }}</div>
          </div>
        </div>
      </div>

      <!-- 日志&提示 -->
      <div class="card" style="grid-column: span 12;">
        <div class="title">日志</div>
        <div class="mono" style="white-space: pre-wrap; line-height:1.5; max-height: 260px; overflow:auto; background:#0f1320; border:1px solid #1f2434; border-radius:12px; padding:10px;">
{{ logs.join('\n') }}
        </div>
      </div>

      <div class="footer card" style="grid-column: span 12;">
        小贴士：
        <ul>
          <li>ERC721 是 NFT 标准，每个代币都是唯一的；</li>
          <li>铸造NFT需要合约支持 mint 函数；</li>
          <li>授权操作员可以管理所有NFT，单个授权只能管理特定NFT；</li>
          <li>确保浏览器已安装 MetaMask 并在目标网络上。</li>
        </ul>
      </div>
    </div>
  </div>

<script>
const { createApp, reactive } = Vue

const ERC721_ABI = [
  // 标准ERC721方法
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function totalSupply() view returns (uint256)",
  "function balanceOf(address owner) view returns (uint256)",
  "function ownerOf(uint256 tokenId) view returns (address)",
  "function tokenURI(uint256 tokenId) view returns (string)",
  "function approve(address to, uint256 tokenId)",
  "function getApproved(uint256 tokenId) view returns (address)",
  "function setApprovalForAll(address operator, bool approved)",
  "function isApprovedForAll(address owner, address operator) view returns (bool)",
  "function transferFrom(address from, address to, uint256 tokenId)",
  "function safeTransferFrom(address from, address to, uint256 tokenId)",
  // 可选：铸造功能（不是ERC721标准，但常见）
  "function mint(address to, string memory uri)",
  // 事件
  "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)",
  "event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId)",
  "event ApprovalForAll(address indexed owner, address indexed operator, bool approved)"
]

// 全局变量，避免 Vue 代理冲突
let _provider = null
let _signer = null
let _token = null

createApp({
  data(){
    return {
      account: '',
      chainId: null,
      connected: false,
      connecting: false,

      tokenAddress: '0x5fbdb2315678afecb367f032d93f642f64180aa3',
      tokenMeta: reactive({ name: '', symbol: '', totalSupply: '0' }),
      tokenError: '',

      myNFTs: [],

      mint: reactive({ to: '', tokenId: '' }),
      transfer: reactive({ from: '', to: '', tokenId: '' }),
      approve: reactive({ to: '', tokenId: '' }),
      approvalAll: reactive({ operator: '', approved: 'true' }),

      query: reactive({ tokenId: '', owner: '-', tokenIdURI: '', tokenURI: '-' }),
      queryApproval: reactive({ tokenId: '', result: '-' }),

      mintHash: '',
      transferHash: '',
      approveHash: '',
      approvalAllHash: '',

      logs: []
    }
  },
  mounted(){
    if(window.ethereum){
      this.logs.push('检测到 window.ethereum，等待连接…')
      // 创建 ethers provider（使用全局变量）
      _provider = new ethers.providers.Web3Provider(window.ethereum)
      // 监听账户/网络变更
      window.ethereum.on?.('accountsChanged', (accs)=>{
        this.account = accs?.[0]||'';
        this.connected = !!this.account;
        _signer = _provider.getSigner()
        if(_token) this.refreshMyNFTs()
      })
      window.ethereum.on?.('chainChanged', (cid)=>{
        this.chainId = cid;
        // 重新创建 provider 和 signer
        _provider = new ethers.providers.Web3Provider(window.ethereum)
        _signer = _provider.getSigner()
        if(_token) this.loadToken()
      })
    } else {
      this.logs.push('未检测到 MetaMask：请安装扩展。')
    }
  },
  methods: {
    prettyChain(id){
      const map = { '0x1':'Ethereum', '0x5':'Goerli', '0xaa36a7':'Sepolia', '0x89':'Polygon', '0x38':'BSC', '0xa4b1':'Arbitrum', '0x2105':'Base' }
      return map[id] || String(id)
    },
    short(v){ return v? v.slice(0,6)+'…'+v.slice(-4) : '' },
    log(msg){ this.logs.unshift(`[${new Date().toLocaleTimeString()}] ${msg}`) },

    async connect(){
      try{
        if(!window.ethereum) throw new Error('未检测到 MetaMask')
        this.connecting = true

        // 使用 ethers provider 连接钱包
        _provider = new ethers.providers.Web3Provider(window.ethereum)
        const accounts = await _provider.send('eth_requestAccounts', [])
        this.account = accounts[0]
        _signer = _provider.getSigner()

        // 获取网络信息
        const network = await _provider.getNetwork()
        this.chainId = '0x' + network.chainId.toString(16)

        this.connected = true
        this.log(`已连接 ${this.account} @ chain ${this.chainId}`)
      }catch(err){
        this.log('连接失败：'+(err?.message||err))
      }finally{ this.connecting = false }
    },

    async loadToken(){
      this.tokenError = ''
      try{
        if(!this.connected) await this.connect()
        if(!ethers.utils.isAddress(this.tokenAddress)) throw new Error('非法合约地址')

        // 使用 ethers 创建合约对象
        _token = new ethers.Contract(this.tokenAddress, ERC721_ABI, _provider)

        // 并行读取元数据
        const [name, symbol, totalSupply] = await Promise.all([
          _token.name(),
          _token.symbol(),
          _token.totalSupply()
        ])

        this.tokenMeta.name = name
        this.tokenMeta.symbol = symbol
        this.tokenMeta.totalSupply = totalSupply.toString()

        this.log(`已加载 ${symbol} (${name}), 总供应量: ${totalSupply}`)
        await this.refreshMyNFTs()

        // 监听 Transfer 事件
        _token.on('Transfer', (from, to, tokenId, event) => {
          this.log(`Transfer: ${from} -> ${to} Token #${tokenId}`)
          // 自己相关时刷新
          if([from?.toLowerCase(), to?.toLowerCase()].includes(this.account?.toLowerCase())){
            this.refreshMyNFTs()
          }
        })
      }catch(err){
        this.tokenError = err?.message || String(err)
        this.log('加载合约失败：'+ this.tokenError)
      }
    },

    async refreshMyNFTs(){
      if(!_token || !this.account) return
      try{
        const balance = await _token.balanceOf(this.account)
        const balanceNum = parseInt(balance.toString())

        this.myNFTs = []

        // 简单实现：假设tokenId从1开始连续
        for(let i = 1; i <= balanceNum; i++) {
          try {
            const owner = await _token.ownerOf(i)
            if(owner.toLowerCase() === this.account.toLowerCase()) {
              this.myNFTs.push({
                tokenId: i,
                owner: owner
              })
            }
          } catch(e) {
            // 跳过不存在的tokenId
          }
        }

        this.log(`刷新我的NFT列表，共 ${this.myNFTs.length} 个NFT`)
      }catch(err){
        this.log('读取NFT列表失败：'+(err?.message||err))
      }
    },

    async queryOwner(){
      try{
        const tokenId = parseInt(this.query.tokenId)
        if(isNaN(tokenId)) throw new Error('Token ID 必须是数字')
        const owner = await _token.ownerOf(tokenId)
        this.query.owner = owner
      }catch(err){ this.query.owner = '-'; this.log('查询所有者失败：'+(err?.message||err)) }
    },

    async queryTokenURI(){
      try{
        const tokenId = parseInt(this.query.tokenIdURI)
        if(isNaN(tokenId)) throw new Error('Token ID 必须是数字')
        const uri = await _token.tokenURI(tokenId)
        this.query.tokenURI = uri
      }catch(err){ this.query.tokenURI = '-'; this.log('查询TokenURI失败：'+(err?.message||err)) }
    },

    async queryApproval(){
      try{
        const tokenId = parseInt(this.queryApproval.tokenId)
        if(isNaN(tokenId)) throw new Error('Token ID 必须是数字')
        const approved = await _token.getApproved(tokenId)
        this.queryApproval.result = approved === ethers.constants.AddressZero ? '无授权' : approved
      }catch(err){ this.queryApproval.result = '-'; this.log('查询授权失败：'+(err?.message||err)) }
    },

    async doMint(){
      try{
        if(!_token) throw new Error('请先加载合约')
        const to = this.mint.to || this.account
        if(!ethers.utils.isAddress(to)) throw new Error('接收地址不合法')
        const tokenId = parseInt(this.mint.tokenId)
        if(isNaN(tokenId)) throw new Error('Token ID 必须是数字')

        // 使用带签名者的合约对象
        const tokenWithSigner = _token.connect(_signer)
        const t = await tokenWithSigner.mint(to, tokenId)

        this.mintHash = t.hash
        this.log(`提交铸造：${this.short(t.hash)}`)

        // 等待交易确认
        const receipt = await t.wait()
        this.log(`铸造成功，区块：${receipt.blockNumber}`)

        await this.refreshMyNFTs()
      }catch(err){ this.log('铸造失败：'+(err?.message||err)) }
    },

    async doTransfer(){
      try{
        if(!_token) throw new Error('请先加载合约')
        const { from, to, tokenId } = this.transfer
        if(!ethers.utils.isAddress(from)) throw new Error('发送者地址不合法')
        if(!ethers.utils.isAddress(to)) throw new Error('接收地址不合法')
        const parsedTokenId = parseInt(tokenId)
        if(isNaN(parsedTokenId)) throw new Error('Token ID 必须是数字')

        // 使用带签名者的合约对象
        const tokenWithSigner = _token.connect(_signer)
        const t = await tokenWithSigner.transferFrom(from, to, parsedTokenId)

        this.transferHash = t.hash
        this.log(`提交转账：${this.short(t.hash)}`)

        // 等待交易确认
        const receipt = await t.wait()
        this.log(`转账成功，区块：${receipt.blockNumber}`)

        await this.refreshMyNFTs()
      }catch(err){ this.log('转账失败：'+(err?.message||err)) }
    },

    async doApprove(){
      try{
        if(!_token) throw new Error('请先加载合约')
        const { to, tokenId } = this.approve
        if(!ethers.utils.isAddress(to)) throw new Error('被授权者地址不合法')
        const parsedTokenId = parseInt(tokenId)
        if(isNaN(parsedTokenId)) throw new Error('Token ID 必须是数字')

        // 使用带签名者的合约对象
        const tokenWithSigner = _token.connect(_signer)
        const t = await tokenWithSigner.approve(to, parsedTokenId)

        this.approveHash = t.hash
        this.log(`提交授权：${this.short(t.hash)}`)

        // 等待交易确认
        const receipt = await t.wait()
        this.log(`授权成功，区块：${receipt.blockNumber}`)
      }catch(err){ this.log('授权失败：'+(err?.message||err)) }
    },

    async doSetApprovalForAll(){
      try{
        if(!_token) throw new Error('请先加载合约')
        const { operator, approved } = this.approvalAll
        if(!ethers.utils.isAddress(operator)) throw new Error('操作员地址不合法')

        // 使用带签名者的合约对象
        const tokenWithSigner = _token.connect(_signer)
        const t = await tokenWithSigner.setApprovalForAll(operator, approved === 'true')

        this.approvalAllHash = t.hash
        this.log(`提交授权操作员：${this.short(t.hash)}`)

        // 等待交易确认
        const receipt = await t.wait()
        this.log(`授权操作员成功，区块：${receipt.blockNumber}`)
      }catch(err){ this.log('设置授权操作员失败：'+(err?.message||err)) }
    },

    explorerBase(){
      // 仅覆盖常见网络，其他网络可自己扩展
      const map = {
        '0x1':'https://etherscan.io',
        '0xaa36a7':'https://sepolia.etherscan.io',
        '0x5':'https://goerli.etherscan.io',
        '0x89':'https://polygonscan.com',
        '0x38':'https://bscscan.com',
        '0xa4b1':'https://arbiscan.io',
        '0x2105':'https://basescan.org'
      }
      return map[this.chainId] || 'https://etherscan.io'
    },
    explorerTx(hash){ return this.explorerBase()+ '/tx/' + hash },

  }
}).mount('#app')
</script>
</body>
</html>